/**
 * Copyright 2009-2017 the original author or authors.
 * <p>
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * <p>
 * http://www.apache.org/licenses/LICENSE-2.0
 * <p>
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.ibatis.builder;

import org.apache.ibatis.cache.Cache;
import org.apache.ibatis.cache.decorators.LruCache;
import org.apache.ibatis.cache.impl.PerpetualCache;
import org.apache.ibatis.executor.ErrorContext;
import org.apache.ibatis.executor.keygen.KeyGenerator;
import org.apache.ibatis.mapping.CacheBuilder;
import org.apache.ibatis.mapping.Discriminator;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMap;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.mapping.ParameterMode;
import org.apache.ibatis.mapping.ResultFlag;
import org.apache.ibatis.mapping.ResultMap;
import org.apache.ibatis.mapping.ResultMapping;
import org.apache.ibatis.mapping.ResultSetType;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.mapping.StatementType;
import org.apache.ibatis.reflection.MetaClass;
import org.apache.ibatis.scripting.LanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.StringTokenizer;

/**
 * @author Clinton Begin
 */
public class MapperBuilderAssistant extends BaseBuilder
{

    private String currentNamespace;
    private final String resource;
    private Cache currentCache;
    private boolean unresolvedCacheRef; // issue #676

    public MapperBuilderAssistant(Configuration configuration, String resource)
    {
        super(configuration);
        ErrorContext.instance().resource(resource);
        this.resource = resource;
    }

    public String getCurrentNamespace()
    {
        return currentNamespace;
    }

    public void setCurrentNamespace(String currentNamespace)
    {
        if (currentNamespace == null)
        {
            throw new BuilderException("The mapper element requires a namespace attribute to be specified.");
        }

        if (this.currentNamespace != null && !this.currentNamespace.equals(currentNamespace))
        {
            throw new BuilderException(
                    "Wrong namespace. Expected '" + this.currentNamespace + "' but found '" + currentNamespace + "'.");
        }

        this.currentNamespace = currentNamespace;
    }

    public String applyCurrentNamespace(String base, boolean isReference)
    {
        if (base == null)
        {
            return null;
        }
        if (isReference)
        {
            // is it qualified with any namespace yet?
            if (base.contains("."))
            {
                return base;
            }
        }
        // is it qualified with this namespace yet?
        if (base.startsWith(currentNamespace + "."))
        {
            return base;
        }
        if (base.contains("."))
        {
            throw new BuilderException("Dots are not allowed in element names, please remove it from " + base);
        }
        return currentNamespace + "." + base;
    }

    public Cache useCacheRef(String namespace)
    {
        if (namespace == null)
        {
            throw new BuilderException("cache-ref element requires a namespace attribute.");
        }
        try
        {
            unresolvedCacheRef = true;
            Cache cache = configuration.getCache(namespace);
            if (cache == null)
            {
                throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.");
            }
            currentCache = cache;
            unresolvedCacheRef = false;
            return cache;
        }
        catch (IllegalArgumentException e)
        {
            throw new IncompleteElementException("No cache for namespace '" + namespace + "' could be found.", e);
        }
    }

    public Cache useNewCache(Class<? extends Cache> typeClass, Class<? extends Cache> evictionClass, Long flushInterval,
                             Integer size, boolean readWrite, boolean blocking, Properties props)
    {
        CacheBuilder builder = new CacheBuilder(currentNamespace);
        builder.implementation(valueOrDefault(typeClass, PerpetualCache.class));
        builder.addDecorator(valueOrDefault(evictionClass, LruCache.class));
        builder.clearInterval(flushInterval).size(size).readWrite(readWrite);
        builder.blocking(blocking).properties(props);
        Cache cache = builder.build();
        configuration.addCache(cache);
        currentCache = cache;
        return cache;
    }

    public ParameterMap addParameterMap(String id, Class<?> parameterClass, List<ParameterMapping> parameterMappings)
    {
        id = applyCurrentNamespace(id, false);
        ParameterMap parameterMap =
                new ParameterMap.Builder(configuration, id, parameterClass, parameterMappings).build();
        configuration.addParameterMap(parameterMap);
        return parameterMap;
    }

    public ParameterMapping buildParameterMapping(Class<?> parameterType, String property, Class<?> javaType,
                                                  JdbcType jdbcType, String resultMap, ParameterMode parameterMode,
                                                  Class<? extends TypeHandler<?>> typeHandler, Integer numericScale)
    {
        resultMap = applyCurrentNamespace(resultMap, true);

        // Class parameterType = parameterMapBuilder.type();
        Class<?> javaTypeClass = resolveParameterJavaType(parameterType, property, javaType, jdbcType);
        TypeHandler<?> typeHandlerInstance = resolveTypeHandler(javaTypeClass, typeHandler);

        return new ParameterMapping.Builder(configuration, property, javaTypeClass).jdbcType(jdbcType)
                                                                                   .resultMapId(resultMap)
                                                                                   .mode(parameterMode)
                                                                                   .numericScale(numericScale)
                                                                                   .typeHandler(typeHandlerInstance)
                                                                                   .build();
    }

    public ResultMap addResultMap(String id, Class<?> type, String extend, Discriminator discriminator,
                                  List<ResultMapping> resultMappings, Boolean autoMapping)
    {
        id = applyCurrentNamespace(id, false);
        extend = applyCurrentNamespace(extend, true);

        if (extend != null)
        {
            if (!configuration.hasResultMap(extend))
            {
                throw new IncompleteElementException("Could not find a parent resultmap with id '" + extend + "'");
            }
            ResultMap resultMap = configuration.getResultMap(extend);
            List<ResultMapping> extendedResultMappings = new ArrayList<ResultMapping>(resultMap.getResultMappings());
            extendedResultMappings.removeAll(resultMappings);
            // Remove parent constructor if this resultMap declares a constructor.
            boolean declaresConstructor = false;
            for (ResultMapping resultMapping : resultMappings)
            {
                if (resultMapping.getFlags().contains(ResultFlag.CONSTRUCTOR))
                {
                    declaresConstructor = true;
                    break;
                }
            }
            if (declaresConstructor)
            {
                Iterator<ResultMapping> extendedResultMappingsIter = extendedResultMappings.iterator();
                while (extendedResultMappingsIter.hasNext())
                {
                    if (extendedResultMappingsIter.next().getFlags().contains(ResultFlag.CONSTRUCTOR))
                    {
                        extendedResultMappingsIter.remove();
                    }
                }
            }
            resultMappings.addAll(extendedResultMappings);
        }
        ResultMap resultMap =
                new ResultMap.Builder(configuration, id, type, resultMappings, autoMapping).discriminator(discriminator)
                                                                                           .build();
        configuration.addResultMap(resultMap);
        return resultMap;
    }

    public Discriminator buildDiscriminator(Class<?> resultType, String column, Class<?> javaType, JdbcType jdbcType,
                                            Class<? extends TypeHandler<?>> typeHandler,
                                            Map<String, String> discriminatorMap)
    {
        ResultMapping resultMapping =
                buildResultMapping(resultType, null, column, javaType, jdbcType, null, null, null, null, typeHandler,
                                   new ArrayList<ResultFlag>(), null, null, false);
        Map<String, String> namespaceDiscriminatorMap = new HashMap<String, String>();
        for (Map.Entry<String, String> e : discriminatorMap.entrySet())
        {
            String resultMap = e.getValue();
            resultMap = applyCurrentNamespace(resultMap, true);
            namespaceDiscriminatorMap.put(e.getKey(), resultMap);
        }
        return new Discriminator.Builder(configuration, resultMapping, namespaceDiscriminatorMap).build();
    }

    public MappedStatement addMappedStatement(String id, SqlSource sqlSource, StatementType statementType,
                                              SqlCommandType sqlCommandType, Integer fetchSize, Integer timeout,
                                              String parameterMap, Class<?> parameterType, String resultMap,
                                              Class<?> resultType, ResultSetType resultSetType, boolean flushCache,
                                              boolean useCache, boolean resultOrdered, KeyGenerator keyGenerator,
                                              String keyProperty, String keyColumn, String databaseId,
                                              LanguageDriver lang, String resultSets)
    {

        if (unresolvedCacheRef)
        {
            throw new IncompleteElementException("Cache-ref not yet resolved");
        }

        id = applyCurrentNamespace(id, false);
        boolean isSelect = sqlCommandType == SqlCommandType.SELECT;

        MappedStatement.Builder statementBuilder =
                new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType).resource(resource);
        statementBuilder = statementBuilder.fetchSize(fetchSize).timeout(timeout).statementType(statementType);
        statementBuilder = statementBuilder.keyGenerator(keyGenerator).keyProperty(keyProperty).keyColumn(keyColumn);
        statementBuilder = statementBuilder.databaseId(databaseId);
        statementBuilder = statementBuilder.lang(lang);
        statementBuilder = statementBuilder.resultOrdered(resultOrdered).resultSets(resultSets);
        statementBuilder = statementBuilder.resultMaps(getStatementResultMaps(resultMap, resultType, id));
        statementBuilder = statementBuilder.resultSetType(resultSetType);
        statementBuilder = statementBuilder.flushCacheRequired(valueOrDefault(flushCache, !isSelect));
        statementBuilder = statementBuilder.useCache(valueOrDefault(useCache, isSelect)).cache(currentCache);

        ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id);
        if (statementParameterMap != null)
        {
            statementBuilder.parameterMap(statementParameterMap);
        }
        MappedStatement statement = statementBuilder.build();
        configuration.addMappedStatement(statement);
        return statement;
    }

    private <T> T valueOrDefault(T value, T defaultValue)
    {
        return value == null ? defaultValue : value;
    }

    private ParameterMap getStatementParameterMap(String parameterMapName, Class<?> parameterTypeClass,
                                                  String statementId)
    {
        parameterMapName = applyCurrentNamespace(parameterMapName, true);
        ParameterMap parameterMap = null;
        if (parameterMapName != null)
        {
            try
            {
                parameterMap = configuration.getParameterMap(parameterMapName);
            }
            catch (IllegalArgumentException e)
            {
                throw new IncompleteElementException("Could not find parameter map " + parameterMapName, e);
            }
        }
        else if (parameterTypeClass != null)
        {
            List<ParameterMapping> parameterMappings = new ArrayList<ParameterMapping>();
            parameterMap = new ParameterMap.Builder(configuration, statementId + "-Inline", parameterTypeClass,
                                                    parameterMappings).build();
        }
        return parameterMap;
    }

    private List<ResultMap> getStatementResultMaps(String resultMap, Class<?> resultType, String statementId)
    {
        resultMap = applyCurrentNamespace(resultMap, true);

        List<ResultMap> resultMaps = new ArrayList<ResultMap>();
        if (resultMap != null)
        {
            String[] resultMapNames = resultMap.split(",");
            for (String resultMapName : resultMapNames)
            {
                try
                {
                    resultMaps.add(configuration.getResultMap(resultMapName.trim()));
                }
                catch (IllegalArgumentException e)
                {
                    throw new IncompleteElementException("Could not find result map " + resultMapName, e);
                }
            }
        }
        else if (resultType != null)
        {
            ResultMap inlineResultMap = new ResultMap.Builder(configuration, statementId + "-Inline", resultType,
                                                              new ArrayList<ResultMapping>(), null).build();
            resultMaps.add(inlineResultMap);
        }
        return resultMaps;
    }

    public ResultMapping buildResultMapping(Class<?> resultType, String property, String column, Class<?> javaType,
                                            JdbcType jdbcType, String nestedSelect, String nestedResultMap,
                                            String notNullColumn, String columnPrefix,
                                            Class<? extends TypeHandler<?>> typeHandler, List<ResultFlag> flags,
                                            String resultSet, String foreignColumn, boolean lazy)
    {
        Class<?> javaTypeClass = resolveResultJavaType(resultType, property, javaType);
        TypeHandler<?> typeHandlerInstance = resolveTypeHandler(javaTypeClass, typeHandler);
        List<ResultMapping> composites = parseCompositeColumnName(column);
        return new ResultMapping.Builder(configuration, property, column, javaTypeClass).jdbcType(jdbcType)
                                                                                        .nestedQueryId(
                                                                                                applyCurrentNamespace(
                                                                                                        nestedSelect,
                                                                                                        true))
                                                                                        .nestedResultMapId(
                                                                                                applyCurrentNamespace(
                                                                                                        nestedResultMap,
                                                                                                        true))
                                                                                        .resultSet(resultSet)
                                                                                        .typeHandler(
                                                                                                typeHandlerInstance)
                                                                                        .flags(flags ==
                                                                                                       null ? new ArrayList<ResultFlag>() : flags)
                                                                                        .composites(composites)
                                                                                        .notNullColumns(
                                                                                                parseMultipleColumnNames(
                                                                                                        notNullColumn))
                                                                                        .columnPrefix(columnPrefix)
                                                                                        .foreignColumn(foreignColumn)
                                                                                        .lazy(lazy).build();
    }

    private Set<String> parseMultipleColumnNames(String columnName)
    {
        Set<String> columns = new HashSet<String>();
        if (columnName != null)
        {
            if (columnName.indexOf(',') > -1)
            {
                StringTokenizer parser = new StringTokenizer(columnName, "{}, ", false);
                while (parser.hasMoreTokens())
                {
                    String column = parser.nextToken();
                    columns.add(column);
                }
            }
            else
            {
                columns.add(columnName);
            }
        }
        return columns;
    }

    private List<ResultMapping> parseCompositeColumnName(String columnName)
    {
        List<ResultMapping> composites = new ArrayList<ResultMapping>();
        if (columnName != null && (columnName.indexOf('=') > -1 || columnName.indexOf(',') > -1))
        {
            StringTokenizer parser = new StringTokenizer(columnName, "{}=, ", false);
            while (parser.hasMoreTokens())
            {
                String property = parser.nextToken();
                String column = parser.nextToken();
                ResultMapping complexResultMapping = new ResultMapping.Builder(configuration, property, column,
                                                                               configuration.getTypeHandlerRegistry()
                                                                                            .getUnknownTypeHandler())
                        .build();
                composites.add(complexResultMapping);
            }
        }
        return composites;
    }

    private Class<?> resolveResultJavaType(Class<?> resultType, String property, Class<?> javaType)
    {
        if (javaType == null && property != null)
        {
            try
            {
                MetaClass metaResultType = MetaClass.forClass(resultType, configuration.getReflectorFactory());
                javaType = metaResultType.getSetterType(property);
            }
            catch (Exception e)
            {
                //ignore, following null check statement will deal with the situation
            }
        }
        if (javaType == null)
        {
            javaType = Object.class;
        }
        return javaType;
    }

    private Class<?> resolveParameterJavaType(Class<?> resultType, String property, Class<?> javaType,
                                              JdbcType jdbcType)
    {
        if (javaType == null)
        {
            if (JdbcType.CURSOR.equals(jdbcType))
            {
                javaType = java.sql.ResultSet.class;
            }
            else if (Map.class.isAssignableFrom(resultType))
            {
                javaType = Object.class;
            }
            else
            {
                MetaClass metaResultType = MetaClass.forClass(resultType, configuration.getReflectorFactory());
                javaType = metaResultType.getGetterType(property);
            }
        }
        if (javaType == null)
        {
            javaType = Object.class;
        }
        return javaType;
    }

    /**
     * Backward compatibility signature
     */
    public ResultMapping buildResultMapping(Class<?> resultType, String property, String column, Class<?> javaType,
                                            JdbcType jdbcType, String nestedSelect, String nestedResultMap,
                                            String notNullColumn, String columnPrefix,
                                            Class<? extends TypeHandler<?>> typeHandler, List<ResultFlag> flags)
    {
        return buildResultMapping(resultType, property, column, javaType, jdbcType, nestedSelect, nestedResultMap,
                                  notNullColumn, columnPrefix, typeHandler, flags, null, null,
                                  configuration.isLazyLoadingEnabled());
    }

    public LanguageDriver getLanguageDriver(Class<?> langClass)
    {
        if (langClass != null)
        {
            configuration.getLanguageRegistry().register(langClass);
        }
        else
        {
            langClass = configuration.getLanguageRegistry().getDefaultDriverClass();
        }
        return configuration.getLanguageRegistry().getDriver(langClass);
    }

    /**
     * Backward compatibility signature
     */
    public MappedStatement addMappedStatement(String id, SqlSource sqlSource, StatementType statementType,
                                              SqlCommandType sqlCommandType, Integer fetchSize, Integer timeout,
                                              String parameterMap, Class<?> parameterType, String resultMap,
                                              Class<?> resultType, ResultSetType resultSetType, boolean flushCache,
                                              boolean useCache, boolean resultOrdered, KeyGenerator keyGenerator,
                                              String keyProperty, String keyColumn, String databaseId,
                                              LanguageDriver lang)
    {
        return addMappedStatement(id, sqlSource, statementType, sqlCommandType, fetchSize, timeout, parameterMap,
                                  parameterType, resultMap, resultType, resultSetType, flushCache, useCache,
                                  resultOrdered, keyGenerator, keyProperty, keyColumn, databaseId, lang, null);
    }

}
